March 29, 2021
이번 프로젝트에서 저는 store에 저장할 것과 state에 저장할 것을 아래와 같이 나누었습니다.
또한, 백엔드는 파이어베이스를 사용했기 때문에, 데이터를 가져올 때 react-query 나 swr 같은 라이브러리는 따로 사용하지 않았습니다.
이전에는 리덕스와 리덕스 미들웨어를 사용하여 전역 store에 API 응답으로부터 오는 모든 데이터를 저장해놨습니다. 그래서 필요할 때 쉽게 꺼내오고, 알아서 캐싱되고, API 응답 처리도 리덕스 사가에서 처리하도록 말입니다.
하지만, 로그인된 유저 정보를 제외하고 API 요청을 통해 가져오는 데이터는 사실 여러 페이지에서 쓰이지 않습니다. 대부분 한 페이지에서만 쓰이게 됩니다.
그런데 그런 (높은 가능성으로 사이즈가 클) 데이터를 전역 상태에 저장해놓는 것은 매우 비효율적으로 보였습니다.
컴포넌트 내부 state 가 setState로 변할 때
스토어가 변할 때
위처럼, 스토어가 변하면 스토어에 저장된 데이터를 사용하는 컴포넌트 뿐만 아니라, 해당 컴포넌트가 속한 최상위 컴포넌트부터 비교하는 연산이 추가됩니다.
UI 상태나 유저의 로그인 정보의 경우는 전역에서 사용되므로 스토어에 저장해놓는게 최선의 방법이겠지만, API 로부터 받은 데이터는 그렇지 않습니다.
API 응답 데이터는 사실상 하나의 페이지 / 하나의 컴포넌트 내부에서만 사용되기 때문입니다. 물론 데이터가 전역에서 사용되어야한다면, 스토어에 저장하는게 맞다고 생각합니다.
그래서 이번 프로젝트를 진행하며 API 요청으로부터 오는 데이터는 모두 해당 페이지 내부 상태(state)에 저장하게 되었습니다. 그러면 여기서 문제가 생깁니다.
state는 새로 렌더링 될 때마다 초기화되고 그렇게 되면 페이지를 벗어났다가 다시 들어오면 모든 데이터를 새로 API로부터 받아와야합니다.
그래서 저는 메모리에 데이터를 캐싱
해놓는 방법을 구현하였습니다.
그렇다면 어떻게 제가 사용했는지 코드로 알아보겠습니다.
const EXPIRE_TIME = 3000000
interface CacheData<T> {
// 캐시 구조 , key와 value
[key: string]: ValueData<T>
}
// 캐시 내부 value 타입
// expiry 를 저장하여 만료시간 기입
interface ValueData<T> {
value: T
expiry: number
}
class cacheProto<T> {
private data: CacheData<T>
constructor() {
this.data = {}
}
// key 에 해당하는 캐시가 있는지 확인
has = (key: string): boolean => {
if (this.data[key]) {
// 만료시간 지나지 않은 경우
if (this.data[key].expiry > new Date().getTime()) {
return true
}
// 만료시간이 지났을 경우 삭제
this.delete(key)
}
return false
}
// key로 데이터를 가져옴
get = (key: string): T | null => {
return this.data[key]?.value || null
}
// key 와 value로 캐시를 저장
set = (key: string, value: T) => {
this.data[key] = {
value,
expiry: new Date().getTime() + EXPIRE_TIME,
}
}
// key에 해당하는 캐시를 삭제
delete = (key: string) => {
delete this.data[key]
}
// 모든 캐시 초기화
clear = () => {
this.data = {}
}
}
export default cacheProto
import * as T from 'types/API'
// 새로운 캐시 객체를 만듭니다.
const CACHE = new cacheProto<T.ReviewData>()
const useSingleReview = () => {
....,
const [singleReview, setSingleReview] = useState<T.ReviewData | null>(null)
// API 응답 처리 useEffect
useEffect(() => {
if (getReviewResult.type === SUCCESS) {
setSingleReview(getReviewResult.data)
// API 로부터 받아온 데이터를 캐시에 저장
CACHE.set(getReviewResult.data.docId, getReviewResult.data)
}
}, [getReviewResult])
// 초기 데이터 가져오는 메서드
const fetchData = useCallback((postId: string) => {
// 캐시가 있을 경우, 캐시 데이터를 저장
if (CACHE.has(postId)) {
return setSingleReview(CACHE.get(postId))
}
// 없을 경우 API 요청
getReviewFetch({ type: REQUEST, params: [postId] })
}, [])
// postId에 해당하는 캐시 삭제
const removeCache = useCallback((postId: string) => {
CACHE.delete(postId)
}, [])
// postId에 해당하는 캐시 업데이트
const updateCache = useCallback((postId: string, data: T.ReviewData) => {
CACHE.set(postId, data)
}, [])
}
export default useSingleReview
위처럼 메모리에 캐시를 저장할 경우, 브라우저를 껐다 끄거나 새로고침하면 캐시가 초기화됩니다.
그렇다면 유저가 A 아이디로 로그인했다가, 로그아웃 후 B아이디로 다시 로그인 할 경우, 두개의 ID는 캐시를 공유하게 됩니다. (중간에 브라우저를 새로고침 하지 않는 이상)
그렇다면 위와 같이 리뷰 데이터에 관한 캐시가 아니라, 특정 유저ID에 한정되어있는 데이터의 경우 유저 ID에 대한 확인을 추가해주어야 합니다. 예를들면 북마크 데이터
가 그렇습니다.
아래는 북마크 정보를 가져오는 로직입니다. CACHE 와 함께 CACHED_USER에 현재 유저의 ID 정보를 저장해놓습니다. 그리고 추후, 초기 데이터 요청이 들어올 경우 현재 로그인된 유저와 캐시에 저장된 유저의 정보가 같은지 확인 후 데이터를 제공 혹은 파기합니다.
const CACHE = new cacheProto<BookMarkListType[]>();
let CACHED_USER = '';
const BookMarkList = ({ userId, pageNum }: Props) => {
....,
useEffect(() => {
if (userId) { // 먼저 userId를 확인
// 캐싱된 유저와 userId 가 일치하는지 확인
if (CACHED_USER === userId && CACHE.has(userId)) {
const cachedData = CACHE.get(userId);
if (cachedData) {
setReviewList(sliceArray(cachedData, pageNum));
setTotalLength(cachedData.length);
}
} else { // 일치하지 않을경우
CACHED_USER = userId;
CACHE.clear();
getBookMarksFetch({ type: REQUEST, params: [userId] });
}
}
}, [userId, pageNum]);
}
이렇게하면 유저에 따라 독립되어야 할 데이터가 서로 다른 두 아이디 사이에 공유될 일이 없습니다.
캐시의 목적은 결국 백엔드에 대한 연산을 줄여주고, 클라이언트 사이드의 데이터 제공속도를 늘리는데에 있습니다.
캐시의 가장 큰 단점은 만료되기 전까지 저장된 데이터가 업데이트 되지 않는 상태로 유지된다는 것입니다.
데이터가 업데이트 되지 않는 경우는 크게 살펴보면 두가지 경우입니다.
예를들어 서버에서 10개의 데이터를 가져와서 캐시에 넣어놓은 후 유저가 접근할 때마다 해당 캐시에 있는 데이터를 제공 할 경우, 이 사이에 새로 들어온 데이터는 제공하지 못하게 됩니다.
예를들어 유저 A가 해당 페이지에 접근 한 후 (캐시가 저장 된 후) , 유저 B가 페이지 내부 데이터를 수정해도 유저 A는 업데이트 된 결과를 볼 수 없습니다.
이를 방지하기 위해서는 어쩔 수 없이 해당 페이지에 진입할 때마다 서버에 새로 요청을 보내야하는 선택을 해야합니다. 하지만 저의 프로젝트의 경우 작은 프로젝트이고, 데이터의 수정 및 삭제가 많이 일어나지 않기 때문에 2번 문제는 제쳐두고, 1번 문제만 해결하기로 했습니다.
useEffect(() => {
// 캐시 유무 확인
if (CACHE.has('general-search')) {
const cachedData = CACHE.get('general-search')
if (cachedData) {
setAllReviews(cachedData.reviews)
setLastKey(cachedData.lastKey)
setHasMore(cachedData.hasMore)
// 저장된 initialKey로 recentReviews 가져오기
recentReviewsFetch({
type: REQUEST,
params: [cachedData.initialKey],
})
}
}
}, [])
이렇게 할 경우, 1번의 문제, 즉 새로 생성된 데이터에 대한 캐시의 out of date 문제는 해결됩니다. 2번까지 해결하기 위해서는 어쩔 수 없이 모든 데이터를 매 요청마다 서버에서 새로 받아오는 수 밖에 없습니다.
이렇게 리액트 캐싱을 구현했습니다. 하지만 조금 찝찝한 면이 있으시지 않으신가요?
지금은 앱의 크기가 작고, 데이터가 많지 않아서 괜찮지만 추후에 데이터가 많아질 경우 너무 많은 데이터를 캐싱하게 될 가능성이 있습니다.
또한 앱의 크기가 클 경우, 더이상 사용하지 않을 데이터를 캐싱하게 될 가능성(메모리 누수)도 높아집니다.
이를 방지하기 위해서는 아래와 같은 방법을 도입하여 개선해볼 수 있다고 생각합니다.
만료기한(expiry)와 함께 배열의 크기의 최댓값을 명시해 줄 수 있습니다. 그래서 새로 들어오는 데이터 값에 의해 최댓값의 개수를 넘어버리면 캐시를 clear 하고 새로운 데이터부터 다시 카운팅 해주는 방식을 구현할 수 있을 것이라 생각합니다.
이렇게 구현하면 캐시의 크기가 필요이상으로 커질 위험이 줄어들 수 있겠습니다.
저의 프로젝트는 크기가 매우 협소하고, 캐시를 사용하는 곳이 많지 않고 차지하는 크기도 적기 때문에 별로 문제될 것은 없지만 추후에 크기가 커지고, 여러 곳에서 캐시를 사용 할 경우 매우 key 값을 정하는게 매우 복잡해지고 헷갈릴 것이라고 생각합니다.
지금은 단순히 postId나 userId와 같은 key 값을 사용하지만 추후에는 해당 키 값을 해시화 하여 저장하거나 url 을 기준으로 키값을 정하면 더 명료하고 안전해지지 않을까생각합니다.
이번 프로젝트를 진행하면서 리덕스와 미들웨어 없이 생짜로 API 요청을 컴포넌트에서 보내고, 받아온 데이터를 메모리에 캐싱하는 과정을 직접 구현 해보았습니다. 이전에 리덕스를 사용 할 때는 별로 이런 것에 대한 고민이 없었습니다. API 요청은 미들웨어에서 처리해주고, 받아온 데이터는 스토어에 저장해주는 로직이 너무 명료해보였거든요. 하지만, 깊게 생각해보면 굳이 스토어에 저장해야하나 싶어서 위와 같이 구현해볼 수 있었습니다.
아직 너무 부족하고, 메모리와 캐시에 관해서도 학습해야할 것 투성이이지만 그래도 이번에 캐시를 구현하면서 참 많은 것을 배워간 것 같아서 기쁩니다.
잘못된 점이나 궁금하신 점은 댓글 남겨주시면 감사하겠습니다.